package gameoflife.gui;

import gameoflife.Cell;
import gameoflife.ChangeListener;
import gameoflife.Position;
import gameoflife.automaton.Automaton;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Point;
import java.awt.Toolkit;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;

import javax.swing.AbstractButton;
import javax.swing.JPanel;
import javax.swing.RepaintManager;

@SuppressWarnings("serial")
public class Display extends JPanel {

	// grid scale constants
	private static final int INITIAL_SCALE = 12;
	private static final int MIN_SCALE = -48;
	private static final int MAX_SCALE = 32;
	private static final int MIN_SCALE_WITH_GRID = 4;

	// screen fitting constants
	private static final float SCREENFIT_RATIO = 0.9f;
	private static final int SMOOTH_ZOOM_DELAY = 100;

	// color constants
	private static final Color BACKGROUND_COLOR = new Color(45, 45, 45);
	private static final Color GRID_COLOR = new Color(70, 70, 70);
	private static final Color GRID_MARK_COLOR = new Color(100, 100, 100);
	private static final int GRID_MEASURE_INTERVAL = 10;
	private static final Color ORIGIN_MARK_COLOR = new Color(180, 180, 180);

	private static final Color NEWBORN_CELL_COLOR = new Color(255, 210, 0);
	private static final Color MATURE_CELL_COLOR = new Color(245, 0, 0);
	private static final Color MULTIPLE_CELLS_COLOR = new Color(255, 255, 255);
	private static final int SATURATION_AGE = 16;

	// model
	private Automaton automaton;

	// grid scale
	private int scale = INITIAL_SCALE;

	// offset from center of grid
	private Point offset = new Point();

	// auto-fitting toggle
	private AbstractButton autoFit = null;

	// show grid lines toggle
	private boolean visibleGridLines = true;

	// change listener
	private ChangeListener changeListener;

	public Display(final Automaton automaton) {
		super();
		this.automaton = automaton;

		Dimension screenSize = Toolkit.getDefaultToolkit().getScreenSize();
		setPreferredSize(new Dimension(2 * screenSize.width / 3, 3 * screenSize.height / 4));
		setBackground(BACKGROUND_COLOR);

		// listen for automaton changes
		automaton.addChangeListener(new ChangeListener() {
			@Override
			public void stateChanged(int changeCode) {
				if (changeCode == Automaton.STEP_CHANGE_CODE && isAutoFitEnabled()) {
					fitToScreen(false);
				}
				repaint();
			}
		});

		// mouse listener
		MouseAdapter mouseListener = new MouseAdapter() {
			private Point dragOffset = new Point();
			private Point dragOrigin = new Point();
			private Boolean dragCellOn;
			private Position dragLastPosition;

			@Override
			public void mouseClicked(MouseEvent e) {
				switch (e.getButton()) {
				case MouseEvent.BUTTON1:
					// smooth zoom-in on double click
					if (e.getClickCount() % 2 == 0) {
						int scaleDelta;
						if (scale > 0) {
							scaleDelta = Math.min(2 * scale, MAX_SCALE - scale);
						}
						else {
							scaleDelta = -2 * scale / 3;
						}
						for (int i = 0; i < scaleDelta; i++) {
							zoom(1, e.getPoint());
							RepaintManager.currentManager(Display.this).paintDirtyRegions();
							try {
								Thread.sleep(SMOOTH_ZOOM_DELAY / scaleDelta);
							} catch (InterruptedException ex) {
							}
						}
					}
					break;
				case MouseEvent.BUTTON3:
					// cell toggling
					if (scale > 0) {
						automaton.toggle(toPosition(e.getPoint()));
					}
					break;
				}
			}

			@Override
			public void mousePressed(MouseEvent e) {
				dragOrigin = e.getPoint();
				dragOffset.move(0, 0);
				dragCellOn = null;
			}

			@Override
			public void mouseDragged(MouseEvent e) {
				switch (e.getModifiersEx() & (MouseEvent.BUTTON1_DOWN_MASK | MouseEvent.BUTTON3_DOWN_MASK)) {

				case MouseEvent.BUTTON3_DOWN_MASK:
					// cell toggling
					if (scale > 0) {
						Position position = toPosition(e.getPoint());
						if (!position.equals(dragLastPosition)) {
							dragLastPosition = position;
							if (dragCellOn == null) {
								dragCellOn = !automaton.isSet(position);
							}
							automaton.set(position, dragCellOn);
						}
					}
					break;

				case MouseEvent.BUTTON1_DOWN_MASK:
					// panning
					pan(e.getX() - dragOrigin.x - dragOffset.x, e.getY() - dragOrigin.y - dragOffset.y);
					dragOffset.move(e.getX() - dragOrigin.x, e.getY() - dragOrigin.y);
					break;

				}
			}

			@Override
			public void mouseWheelMoved(MouseWheelEvent e) {
				zoom(- e.getWheelRotation(), e.getPoint());
			}
		};
		addMouseListener(mouseListener);
		addMouseMotionListener(mouseListener);
		addMouseWheelListener(mouseListener);
	}

	public Automaton getAutomaton() {
		return automaton;
	}

	public void setChangeListener(ChangeListener changeListener) {
		this.changeListener = changeListener;
	}

	private void setScale(int scale) {
		this.scale = scale;
		changeListener.stateChanged(0);
	}

	public int getScale() {
		return scale;
	}

	public String getScaleString() {
		return scale > 0 ? ("1:" + scale) : (-scale + ":1");
	}

	public Position toPosition(Point p) {
		Point center = getCenter();
		int x = p.x - center.x;
		int y = p.y - center.y;
		if (scale > 0) {
			x = x < 0 ? (x + 1) / scale - 1 : x / scale;
			y = y < 0 ? (y + 1) / scale - 1 : y / scale;
		}
		else {
			x = -(x * scale + scale / 2);
			y = -(y * scale + scale / 2);
		}
		return new Position(x, y);
	}

	public void recenter() {
		offset.move(0, 0);
		setAutoFitEnabled(false);
		repaint();
	}

	public void pan(int dx, int dy) {
		offset.translate(dx, dy);
		setAutoFitEnabled(false);
		repaint();
	}

	public void zoom(int zoom) {
		zoom(zoom, new Point(getWidth() / 2, getHeight() / 2));
	}

	public void zoom(int zoom, Point point) {
		Position zoomPos = toPosition(point);

		int newCellSize = Math.max(Math.min(scale + zoom, MAX_SCALE), MIN_SCALE);
		if (newCellSize == scale) {
			return;
		}
		if (newCellSize == -1 || newCellSize == 0) {
			newCellSize = zoom > 0 ? 1 : -2;
		}
		setScale(newCellSize);

		// re-focus on zoom position
		int centerX = getWidth() / 2;
		int centerY = getHeight() / 2;
		if (scale > 0) {
			offset.move(point.x - centerX - (scale * zoomPos.getX() + scale / 2),
					    point.y - centerY - (scale * zoomPos.getY() + scale / 2));
		}
		else {
			int x = zoomPos.getX();
			int y = zoomPos.getY();
			offset.move(point.x - centerX - (x < 0 ? (x + 1) / -scale - 1 : x / -scale),
				        point.y - centerY - (y < 0 ? (y + 1) / -scale - 1 : y / -scale));
		}

		setAutoFitEnabled(false);
		repaint();
	}

	public void setVisibleGridLines(boolean visible) {
		visibleGridLines = visible;
		repaint();
	}

	public boolean getVisibleGridLines() {
		return visibleGridLines;
	}

	public void setAutoFitButton(AbstractButton button) {
		autoFit = button;
	}

	public void setAutoFitEnabled(boolean enabled) {
		if (autoFit != null) {
			autoFit.setSelected(enabled);
		}
	}

	public boolean isAutoFitEnabled() {
		return autoFit != null && autoFit.isSelected();
	}

	public void fitToScreen() {
		fitToScreen(true);
		repaint();
	}

	private int minX, maxX, minY, maxY;

	private void fitToScreen(boolean forceFit) {
		minX = minY = Integer.MAX_VALUE;
		maxX = maxY = Integer.MIN_VALUE;
		automaton.accept(new Automaton.Visitor() {
			@Override
			public void visit(Cell cell) {
				if (cell.getX() < minX) {
					minX = cell.getX();
				}
				else if (cell.getX() > maxX) {
					maxX = cell.getX();
				}
				if (cell.getY() < minY) {
					minY = cell.getY();
				}
				else if (cell.getY() > maxY) {
					maxY = cell.getY();
				}
			}
		});

		Position minXY = toPosition(new Point(0, 0));
		Position maxXY = toPosition(new Point(getWidth(), getHeight()));
		boolean fitsScreen =
			minX > minXY.getX() && maxX < maxXY.getX() && 
			minY > minXY.getY() && maxY < maxXY.getY();

	    if (!fitsScreen || forceFit) {
			float fScale = Math.min((float)getWidth() / (maxX - minX + 1),
					                (float)getHeight() / (maxY - minY + 1));
			fScale *= SCREENFIT_RATIO;
			if (fScale >= 1.0) {
				setScale(Math.min((int) Math.floor(fScale), MAX_SCALE));
				offset.move(-scale * (minX + maxX) / 2,
							-scale * (minY + maxY) / 2);
			}
			else {
				setScale(Math.min(Math.max((int)Math.floor(-1 / fScale), MIN_SCALE), -2));
				int x = (minX + maxX) / 2;
				int y = (minY + maxY) / 2;
				offset.move(-(x < 0 ? (x + 1) / -scale - 1 : x / -scale),
							-(y < 0 ? (y + 1) / -scale - 1 : y / -scale));
			}
		}
	}

	@Override
	protected void paintComponent(final Graphics g) {
		super.paintComponent(g);

		final Point center = getCenter();

		// grid
		if (isGridOn() && visibleGridLines) {
			for (int x = center.x % scale; x < getWidth(); x += scale) {
				g.setColor((x - center.x) % (scale * GRID_MEASURE_INTERVAL) == 0 ? GRID_MARK_COLOR : GRID_COLOR);
				g.drawLine(x, 0, x, getHeight());
			}
			for (int y = center.y % scale; y < getHeight(); y += scale) {
				g.setColor((y - center.y) % (scale * GRID_MEASURE_INTERVAL) == 0 ? GRID_MARK_COLOR : GRID_COLOR);
				g.drawLine(0, y, getWidth(), y);
			}
		}

		// cells
		if (scale > 0) {
			automaton.accept(new Automaton.Visitor() {
				int gridLineSize = isGridOn() ? 1 : 0;
				int side = scale - gridLineSize;
				int offsetX = center.x + gridLineSize;
				int offsetY = center.y + gridLineSize;
				@Override
				public void visit(Cell cell) {
					g.setColor(getAgeColor(cell.getAge()));
					g.fillRect(offsetX + scale * cell.getX(),
							   offsetY + scale * cell.getY(),
							   side, side);
				}
			});
		}
		else {
			g.setColor(MULTIPLE_CELLS_COLOR);
			automaton.accept(new Automaton.Visitor() {
				@Override
				public void visit(Cell cell) {
					int x = cell.getX();
					int y = cell.getY();
					g.fillRect(center.x + (x < 0 ? (x + 1) / -scale - 1 : x / -scale),
							   center.y + (y < 0 ? (y + 1) / -scale - 1 : y / -scale),
							   1, 1);
				}
			});
		}

		// center mark
		if (visibleGridLines) {
			g.setColor(ORIGIN_MARK_COLOR);
			g.drawLine(center.x - 1, center.y, center.x + 1, center.y);
			g.drawLine(center.x, center.y - 1, center.x, center.y + 1);
		}
	}

	private Point getCenter() {
		return new Point(getWidth() / 2 + offset.x, getHeight() / 2 + offset.y);
	}
	
	private boolean isGridOn() {
		return scale >= MIN_SCALE_WITH_GRID;
	}

	private Color getAgeColor(int age) {
		if (age >= SATURATION_AGE)
			return MATURE_CELL_COLOR;
		return CELL_COLORS[age * CELL_COLORS.length / SATURATION_AGE];
	}
	
	// color gradation table
	private static final Color[] CELL_COLORS;
	static {
		Color a = NEWBORN_CELL_COLOR;
		Color b = MATURE_CELL_COLOR;
		double dR = b.getRed() - a.getRed();
		double dG = b.getGreen() - a.getGreen();
		double dB = b.getBlue() - a.getBlue();
		double dMax = Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB)));
		dR /= dMax;
		dG /= dMax;
		dB /= dMax;
		CELL_COLORS = new Color[(int)dMax];
		for (int i = 0; i < dMax; i++) {
			CELL_COLORS[i] = new Color(
					(int)(dR*i) + a.getRed(),
					(int)(dG*i) + a.getGreen(),
					(int)(dB*i) + a.getBlue());
		}
	}

}
